iOS15 动态链接 fixup chain 原理详解
技术干货哪里找?
👆 点击上方蓝字关注我们!
All programs and dylibs
built with a deployment target of macOS 12 or iOS 15 or later now use the chained fixups format. This uses different load commands and LINKEDIT data, and won’t run or load on older OS versions. (49851380)
下面这张图简要描述了使用 fixup chain 进行动态链接之后,静态链接器所生成 Mach-O 文件的结构
更紧凑的存储信息格式所带来的更小的二进制产物
更好的空间局部性所带来的更快的应用启动时间
背景
动态链接过程中会涉及到两类符号:lazy symbol 和 non-lazy symbol,这两种符号的区别时它们的地址被修正为内存中这些符号真实地址的时机;对于 lazy symbol 而言,这些符号的加载是在第一次调用它们的时候,由 dyld 来找到外部动态库中这些符号的正确地址然后进行调用;对于 non-lazy symbol 而言,这些符号在二进制程序被加载进内存时就被 dyld 定位到符号地址并记录下来,在调用时可以直接调用。
dyld 是苹果 MacOS / iOS 系统上的动态链接器,负责在程序装载进内存时 / 程序运行时,将主二进制中引用外部符号的地址指向加载到内存中的外部动态库中对应符号所定义的位置,并对内部的一些数据指针所存储的地址的值进行修正;这个过程有两个核心阶段,rebase 和 binding。
Rebase 阶段
Rebase 的过程与一种叫做 ASLR 的特性密不可分,ASLR(Address Space Layout Randomization,地址空间随机化)是一种操作系统用来抵御缓冲区溢出攻击的内存保护机制,苹果从iOS 4.3之后引入了这个特性。ASLR 的本质实际上非常简单,当 Mach-O 文件载入虚拟内存的时候,ASLR 会随机向加载到内存的 Mach-O 文件向后移动一段距离(被称为 slide),使得起始地址不从 0x0000 开始,而是从一个随机值,例如 0x5000 开始(也就是 slide=0x5000),并且后面的函数地址都会增加 0x5000;这使得攻击者通过缓冲区溢出来执行任意函数的过程变得非常困难,因为 ASLR 所移动的大小是随机的,因此攻击者无法得知进行移动之后他所希望执行的函数地址。总的来说,ASLR 增加了攻击者预测目的地址的难度,防止攻击者直接定位代码位置,阻止溢出攻击。
Rebase 表示 dyld 根据 ASLR 产生的 slide 来修正二进制程序中对应地址的过程;例如程序中引用的某个符号的地址为 0x10018,而由于 ASLR,程序载入的初始地址从 0x10000 变成了 0x15000(移动了 5000 字节),因此要将这个 0x10018 的地址通过 rebase 修正为 0x15018。
未开启 ASLR:
开启 ASLR:
Binding 阶段
Binding 表示 dyld 将二进制程序中引用的外部动态库符号的地址指向内存中加载好的这些动态库的地址;这个过程需要借助二进制程序(在苹果的操作系统上为 Mach-O 格式)的 __DATA segment 中 __la_symbol_ptr(lazy symbol)和 __nl_symbol_ptr(non-lazy symbol)两个 section;这两个 section 用来保存指向符号真实地址位置的指针,但第一次访问这个 section 中的符号时,dyld 还没有找到这些符号的真实地址,因此此时它们会指向 __TEXT segment 中 __stub_helper section 的位置,然后调用 dyld_stub_binder
寻找加载好的动态库中相应符号的地址,找到地址之后,dyld 会用这个地址覆盖 __la_symbol_ptr / __no_symbol_ptr 中原本指向 __stub_helper section 的内容,这样下一次访问这些符号时,可以直接跳转到符号的真实地址,无需再去通过 __stub_helper 定位符号地址。
其大概流程如下
第一次(符号 bind 至真实地址前):
第二次(符号 bind 至真实地址后):
压缩字节流方案
Rebase info:用来进行 rebase 操作的一系列 opcode stream
Bind info:用来进行 bind 操作的一系列 opcode stream,用于 non-lazy symbol(__nl_symbol_ptr section 中的符号)
Lazy info:用来进行 bind 操作的一系列 opcode stream,用于 lazy symbol(__la_symbol_ptr section 中的符号)
Weak info:用来进行 bind 操作的一系列 opcode stream,与 bind info 的区别是这部分 info 用于 bind weak symbols,也就是说如果由于没找到这个符号而链接失败的话,不会直接触发 SIGTRAP 导致程序崩溃,而是返回一个 NULL 表示未找到这个符号,并且将后续使用这个符号时的处理流程交给开发者来解决;
Export info:用于 export 符号
通过压缩字节流进行 rebase
Rebase 的过程大概可以理解为,dyld 执行 rebase opcode 指令流,从而获取到表中 segment,section,address,type 这些字段的数据,并且并且根据这些信息进行 rebase 操作,rebase 得到的结果就是这个表中 value 字段的值,也就是加上 slide 之后的内存地址;然后 dyld 将这个值写入到 address 字段的虚拟内存地址所对应的 Mach-O 加载到内存中的真实地址的位置。
通过压缩字节流进行 bind
这里列举了一部分用于进行bind操作的opcode;当dyld开始bind流程时,设想你有下面所展示的这个表:
举例:
Bind 的过程大概可以理解为,dyld 执行 bind opcode 指令流,从而获取到表中 segment,section,address,type,addend,dylib,symbol 这些字段的数据,并且根据这些信息进行 bind 操作,bind 得到的结果就是这个符号在加载到内存中的外部动态库中的地址;然后 dyld 将这个值写入到 address 字段的虚拟内存地址所对应的 Mach-O 加载到内存中的真实地址的位置。
压缩字节流存在的问题
Fixup chain 方案
Fixup chain 是 iOS15 系统新引入的用来 rebase / bind 的新方案。对 iOS15 及以上的系列来说,rebase / binding 不再需要使用上文所述的 opcode stream 的方式来操作,而是以一个 fixup chains 来代替。
Fixup chain 可以看作是一个链表,其中每个节点存储了一条如何进行 rebase / bind 操作的信息,并且还带有指向下一个链表地址的信息;dyld 可以逐个节点遍历这条链表,对每个节点根据其提供的信息进行 rebase / bind,最终完成整个 rebase / bind过程。
__got
section 是这样的:Fixup chain 结构综述
尽管 fixup chain 本质上可以简单理解为只是一个链表结构,但实际上在 Mach-O 文件中,fixup chain 的结构仍然具有一定的复杂性,这里以一个实际的 Mach-O 文件举例,fixup chain 在 Mach-O 中的结构如下图所示:
其中涉及到的各个结构体如下所示:
linkedit_data_command
位置在 Load command 部分中
用于表示 LC_DYLD_CHAINED_FIXUPS 这个 load command,结构体内部的 dataoff 字段指向 chained fixups header 结构体。
dyld_chained_fixups_header
位置在 __LINKEDIT 段中
用于表示 Mach-O 文件中 fixup chain 的头部信息,结构体内部的 starts offset,imports offset 和 symbols offset 字段指向 chain starts in image,chained import 数组和 symbol name 池。
dyld_chained_starts_in_image
位置在 chained fixups header 后面
指向 chained starts in segment 的位置,Mach-O 有几个segment(用 seg count字段表示),chained starts in image 就包含几条 seg offset 用于表示 chained starts in segment 的偏移量(有的 segment 可能没有,这样的话 offset 就用 0 表示)。
dyld_chained_starts_in_segment
位置在 chained starts in image 后面
用于表示一个 segment 内 fixup chain 的起始信息,dyld 可以从这个结构体开始,逐个遍历 fixup chain 的各个节点;其 page count 字段表示了这个 segment 中有几个 page 拥有 fixup chain,其 page start 字段指向这个 segment 各个包含 fixup chain 的 page 中第一个 fixup chain 节点的位置;对于 64 位系统而言,最常见的两种 fixup chain 节点是 chained ptr 64 bind 和 chained ptr 64 rebase。
dyld_chained_import
位置在 chained starts in segment 后面
用于表示 Mach-O 文件引入的外部符号信息;每个 chained import 结构体中表示通过动态链接引入的一个符号,其 lib ordinal 字段表示了这个符号来自于哪个动态库(以 LC_LOAD_DYLIB load command 的索引形式表示),name offset 字段表示了这个符号的名称(通过去 symbol name 池中对应偏移量的位置找到以null结尾的字符串形式表示的符号名)。
dyld_chained_ptr_64_rebase
/dyld_chained_ptr_64_bind
Load command
首先,如果一个 Mach-O 采用了 fixup chain,那么在 load command 中一定会有一个 LC_DYLD_CHAINED_FIXUPS 的 load command,其结构体如下
__LINKEDIT
段的起始处:我们知道了 LC_DYLD_CHAINED_FIXUPS
的 payload 就在 0x100014000 位置。
dyld_chained_fixups_header
因此这个 chained fixups header 应该是:
starts offset=32 表示 chained starts in image 结构体在相对 chain_data 起始地址+32字节的位置;chain data 就是 linkedit data command 里面 data offset 的地址,也就是 chained fixups header 这个结构体自己的起始地址。由于 chained fixups header 大小正好32字节(算上内存对齐),所以 chained starts in image 其实就在它后面。
imports offset =104 表示 chained import 结构体数组就在相对于这个结构体起始地址 104 字节的位置,这 104 字节除去 chained fixups header 自己所占的 32 字节之外,还包含了一个 chained starts in image 结构体(大小 24 字节),以及两个 chained starts in segment 结构体(每个大小 24 字节,共 48 字节);imports count 表示一共有135个 chained import 结构体。
symbols offset 表示 symbol name 池在相对于这个结构体起始地址 644 字节的位置;由于一个 chained import 结构体的大小是 4 字节,104+4*135=644,因此 symbol name 池就在包含了135个 chained import 的结构体数组后面;symbols_format=0 表示 symbol name 没被压缩;这个 symbol name 池以字符串形式保存了动态链接需要的符号的名字。
dyld_chained_import 结构体数组
我们找出第一个 chained import 的内存地址,也就是 chained fixups header 地址加上 import offset:0x100014000+104=0x100014068
lib ordinal=0x00001111=15,表示这个符号是来自于第 15 个 LC_LOAD_DYLIB 命令引入的动态库 weak import=0,表示这个符号不是 weak symbol name offset=0b1=1,表示符号相对于 symbol pool 起始地址的偏移量是 1,我们找到 symbol name 池偏移量为 1 的位置,发现这个符号名为 _$s10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF
依次类推,我们可以得到全部 135 个这样的结构体。
生成 targetAddrs 数组
获得全部 135 个 chained import 结构体后,dyld 会在 MachOAnalyzer ::
forEachChainedFixupTarget 函数中生成一个包含 binding 过程需要全部动态库信息的 bindTargets 数组,其大概流程如下:
大概流程就是 dyld 遍历每一个 chained import 结构体,通过 name offset 去 symbol name 池中获取它们的符号名,然后通过 lib ordinal 获取它们来自于通过哪个 Load command 加载的动态库,以及这些符号是否是 weak symbol;在获取了这些信息之后,dyld 将它们传入一个 callback 回调函数进行下一步处理;这个回调函数的定义如下:
dyld_chained_starts_in_image
seg count 就是 seg_info_offset 数组长度
seg_info_offset 数组中的每一个entry表示了每个 chained starts in segment 相对于 chained starts in image (也就是这个结构体自己)的起始地址偏移量;chained starts in segment 描述了每一个 segment 中 fixup chain 的信息
__PAGEZERO: 0 (这个段中没有 fixup chain) __TEXT: 0(这个段中没有 fixup chain) __DATA_CONST: 24(dyld_chained_starts_in_image 结构体就是24字节,所以就是紧跟在这个 dyld_chained_starts_in_image 的后面) __DATA: 48(dyld_chained_starts_in_segment 结构体大小也是24字节,所以就是跟在上一个 dyld_chained_starts_in_segment 的后面) __LINKEDIT: 0(这个段中没有 fixup chain)
dyld_chained_starts_in_segment
chained starts in image 后面紧跟的就是 chained starts in segment 池,也就是一个包含了 chained starts in segment 结构体的数组;这个结构体的定义如下:
也就是说这个结构体
大小是 24 字节
page 大小 0x4000
DYLD_CHAINED_PTR 类型为 DYLD_CHAINED_PTR_64_OFFSET
segment_offset 表示这个页所在的segment的起始地址的偏移量(就是 __DATA_CONST 段的 VM 地址)
max_valid_pointer 给 32 位 OS 用的,64 位 OS 先不管
page array 中只有一个 entry,也就是这个段只有一个页;每个 entry 表示本段中每个页中第一个 fixup chain 的偏移量(没有的话这个值为 DYLD_CHAINED_PTR_START_NONE=0xFFFF)
这唯一的一个页中第一个 fixup chain 的偏移量为 0
其代表的结构体内容为:
也就是说这个结构体:
大小是 24 字节
page 大小 0x1000
DYLD_CHAINED_PTR 类型为 DYLD_CHAINED_PTR_64_OFFSET
segment_offset 表示这个页所在的segment的起始地址的偏移量(就是 __DATA 段的 VM 地址)
max_valid_pointer 给 32 位 OS 用的,64 位 OS 先不管
page array 中只有一个 entry,也就是这个段只有一个页;每个 entry 表示本段中每个页中第一个 fixup chain 的偏移量(没有的话这个值为 DYLD_CHAINED_PTR_START_NONE=0xFFFF)
这唯一的一个页中第一个 fixup chain 的偏移量为 24
__DATA_CONST 段
我们先看 __DATA_CONST 段的,根据 __DATA_CONST 段的 LC_SEGMENT_64 load command 得到这个段的起始地址 0x000000010000c000,加上刚刚我们获取到的 __DATA_CONST 段的 dyld_chained_starts_in_segment 中的偏移量 0,得到 0x000000010000c000(发现正好是在 __got section 中)。
由于 64 位中 bind 对应的 bit 为 1,所以我们可以知道这是个 bind 节点;根据 chained ptr 64 bind 结构体的定义:
前 24 位 ordinal 表示这个符号是通过 targetAddrs 数组索引为 0 的元素引入的
addend=0b0=0
reserved 全是 0
next 是 0x2=2,表示 fixup chain 的下一个节点在哪(本节点地址 + stride*next,stride 为 4 字节)
bind 为 1,表示这是个 bind 节点(chained ptr 64 bind)
因此这里存放的结构体为:
前 24 位 ordinal 表示这个符号是通过 targetAddrs 数组索引为 1 的元素引入的
addend=0b0=0
reserved 全是 0
next 是 0x2=2,表示 fixup chain 的下一个节点在哪(本节点地址 + stride*next,stride 为 4 字节)
bind 为 1,表示这是个 bind 节点(chained ptr 64 bind)
跟我们 dyld chained pointer 包含的信息完全匹配。
其中 pointer 字段存储的就是 chained ptr 64 bind 结构体的内存内容,在 dyld 进行 bind 操作之后,这个 64 位的 pointer 将会被替换为符号的真实地址, chained ptr 64 bind 原本存储的信息由于 bind 已经完成,这段信息已经发挥完了自己的作用,因此可以直接被覆写为符号的真实地址,原有信息可以直接抛弃。
__DATA段
__DATA 段的,__DATA_CONST 段起始地址 0x0000000100010000,加上我们之前获取到的 __DATA 段的 chained starts in segment 中的偏移量 24,得到 0x100010018,其内存内容为:
由于 64 位中 bind 对应的 bit 为 0,所以我们可以知道这是个 rebase 节点;根据 chained ptr 64 rebase 结构体的定义:
前 36 位表示 target 在运行期的偏移量位置,也就是 44064
high8=0b0=0
reserved 是 0
next 是 0xe=14
bind 为 0,表示这是个 rebase 节点(chained ptr 64 rebase)
我们通过 next=14 找出这个 fixup chain 的下一个节点,也就是 0x100010018(当前节点地址)+4(stride)*14=0x100010050:
跟我们 dyld chained pointer 包含的信息完全匹配。
其中 pointer 字段存储的就是 chained ptr 64 rebase 结构体的内存内容,在 dyld 进行 rebase 操作之后,这个 64 位的 pointer 将会被替换为内存中的真实地址, chained ptr 64 rebase 原本存储的信息由于 rebase 已经完成,这段信息已经发挥完了自己的作用,因此可以直接被覆写为符号的真实地址,原有信息可以直接抛弃。
总体结构
使用 fixup chain 的 Mach-O 二进制文件的总体结构如下图所示:
通过 fixup chain 进行 rebase / bind
fixupAllChainedFixups
整个 fixup 流程的入口,用来处理 Mach-O 中的所有 fixup chain;基本上就是通过 block 定义了一个闭包,然后调用forEachFixupInAllChains;这个闭包会一路传递下去,直到在 walkChain 函数中作为处理 chain rebase / bind 操作的 handler 被使用。
forEachFixupInAllChains
forEachFixupInAllChains 会逐个遍历 Mach-O 中的每个 segment,并且调用 forEachFixupInSegmentChains 对每个 segment 中的 fixup chain 进行处理。
forEachFixupInSegmentChains
用来处理每个 segment 中的 fixup chain;forEachFixupInAllChains会逐个遍历这个 segment 中包含 fixup chain 的 page(chained starts in segment 结构体中的 page count 和 page offset 字段描述了这些 page 的信息),并且对每个 page,调用 walkChain 对其 fixup chain 进行处理。
walkChain
沿着 fixup chain 一路进行 rebase / bind 操作,直到到达 fixup chain 末尾;walkChain 会对每个节点使用从 fixupAllChainedFixups 一路传递过来的 block,这个 block 会根据每个节点的的 bind 字段来判断当前节点是 rebase 还是 bind 节点,并且进行相应的 rebase / bind 操作。
这个 block 中的关键流程如下:
值得注意的是,在测试的时候我发现这个 targetAddr 数组里保存的引入符号的顺序与 bind 过程中需要 bind 的未定义符号的顺序是一致的,也就是说当 dyld 去 bind 第一个符号时,这个符号也是 targetAddr 中的第一个符号(ordinal=0);当 dyld 去 bind 第二个符号时,第二个符号也是 targetAddr 中的第二个符号(ordinal=1);也就是说随着 dyld 完成 bind 过程,dyld 会顺序访问 targetAddr 数组。这个现象是静态链接器(ld64)进行的一个优化手段,链接器通过保证 bind 的符号与这里 import 的符号顺序相同,使得 dyld 在访问 targetAddr 时会进行顺序访问,而这个过程是拥有很好的空间局部性的,对 bind 过程的速度会有一定提升效果。
Fixup chain 的优势
减小 App 包大小
dyld 加载 Mach-O 文件的 rebase / bind 过程,实质上就是“从一个地方获取到 dyld 进行 rebase / bind 操作必要的信息” 和 “dyld 通过这些信息获取到符号的正确地址并将地址写入到某个位置”两部分过程。那么我们对比一下 fixup chain 和原来使用 opcode 和立即数组成的指令流的方案:
指令流
指令流方案中,“从一个地方获取到 dyld 进行 rebase / bind 操作必要的信息”的过程,就是 dyld 访问 rebase info / binding info 的过程;而“dyld 通过这些信息获取到符号的正确地址并进行写入”就是将 rebase table / binding table 中的 pointer 字段指向的地址替换为对应符号真实地址的过程。
dyld 进行 rebase 的过程就是通过执行这一系列 rebase opcodes,将 Mach-O 中未添加 slide 偏移量的地址修正为 ASLR 之后加上 slide 偏移量的实际地址,而 bind 的过程就是通过执行这一系列 bind opcodes,将 Mach-O 中引入外部符号的地址修正为加载到内存中的动态库对应符号的地址。
Fixup chain
Fixup chain 方案中,Mach-O 里不再存在所谓的 rebase opcodes 和 bind opcodes,这两部分用于指示 dyld 如何进行 rebase 和 bind 的信息在 fixup chain 方案中被存放在 dyld 将要在 rebase / bind 过程中写入的 64 位地址的内存区域,可以通过 otool -dyld_opcodes CrashTest2 验证这一点:在使用了 fixup chain 的 Mach-O 里面,otool 是 dump 不出来给 dyld 使用的 opcode 的。
我们可以通过 MachOView 对比两种方案生成的 Mach-O 的 __DATA_CONST 段的 __got section 部分,
不使用 fixup chain:
可以看到当不使用 fixup chain 时,Data 字段的值都是 0(空指针),在 dyld 完成 bind 之后这个值才会被替换为真实地址,也就是说在 dyld 进行 bind 之前,这 64 位的空间实际上是被浪费了,并没有提供任何有效的信息;而 fixup chain 则利用了这部分信息,
使用 fixup chain:
(fixup chain方案废除了 lazy symbol,全部符号都变成 non-lazy symbol,全部在加载二进制时进行 bind ,所以这里相比用于不使用 fixup chain 部署于旧版本 iOS 系统的 Mach-O 二进制文件多了很多符号)
可以看到相比不使用 fixup chain 的 Mach-O 文件,Data 字段已经不再是空指针,而是存放了64位大小的 ChainedFixupPointerOnDisk union(可以是 chained ptr 64 rebase,chained ptr 64 bind,或者其它类型的fixup chain节点),而 dyld 则会利用这部分信息进行 rebase / bind。
通过使用 fixup chain 的方式,用于rebase / bind 的信息可以被一种更加紧凑的方式编码进 Mach-O中,从而减小 Mach-O 文件的大小;根据 https://www.emergetools.com/blog/posts/iOS15LaunchTime 一文中作者的测试结果来看,使用 fixup chain 之后,大约能够节省将近 50% 的用于动态链接的信息所使用的空间大小。
更快的 rebase 和 bind 过程
Fixup chain 方案除了可以带来更紧凑的 Mach-O 二进制产物以外,还可以优化动态链接过程的速度。
当一个 Mach-O 开始被加载进内存时,iOS 系统并不会在第一时间加载整个 Mach-O,而是等到操作系统需要访问这个 Mach-O 的某个 page 时,才会触发 page fault,通过操作系统将要访问的这个 page 加载到内存中来。对于较长时间没有用到的 page,iOS 会对它们进行压缩处理,作为额外的优化手段。
在动态链接过程中,无论是 rebase 还是 bind,都会触发 dyld 改写某个地址,因此包含这个地址的 page 此时一定会被加载进内存中。无论 dyld 是采用 fixup chain 还是 opcode stream 进行动态链接,要改写的地址数量都是一样多的,因此这个过程中触发的 page fault 次数也不会相差很多。尽管如此,iOS 压缩 page 的这个优化却决定了两个方案速度上的差异。对于 opcode stream 的方式来说,dyld 会遍历一遍 rebase opcodes,然后遍历一遍 bind opcodes,并且两次遍历会访问到的 page 是存在重叠的;那么,当 rebase 过程结束后,这些 page 将会被 iOS 进行压缩处理,但是由于 binding 过程还会访问这些重叠的 page,操作系统此时会对这些 page 进行解压处理;而 fixup chain 方案则避免了这种重复“压缩页-解压页”的情况,由于 fixup chain 是遍历“每个 segment 中含有 fixup chain 的 page”,并且这一遍遍历是同时适用于 rebase 和 bind 两种 fixup chain 的,因此,遍历完每个 page 的 fixup chain 后,dyld 在这一遍操作中就完成了当前 page 的全部 rebase 和 bind 操作,并且不需要在后续流程中回到这个 page 进行访问了;在后续流程中即使 iOS 压缩了这个 page 也不会有影响,因为 dyld 是不会再访问到这个 page 使得操作系统进行解压的,避免了无谓的反复“压缩页-解压页”操作。对 rebase / bind 流程的这个优化,使得 App 可以更快的在 iOS15 系统上启动。
2. 使用 fixup chain 的 dyld 可以一遍完成 rebase / bind 操作,避免了在前后不同时间反复访问同一个内存页,避免了操作系统对这个 page 进行多余的压缩-解压操作,提升了整个动态链接过程的效率。
🔥 火山引擎 APMPlus 应用性能监控是火山引擎应用开发套件 MARS 下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。
目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。